<?php

/*
Copyright 2009-2011 Sam Weiss
All Rights Reserved.

This file is part of Spark/Plug.

Spark/Plug is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

if (!defined('spark/plug'))
{
	header('HTTP/1.1 403 Forbidden');
	exit('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN"><html><head><title>403 Forbidden</title></head><body><h1>Forbidden</h1><p>You don\'t have permission to access the requested resource on this server.</p></body></html>');
}

// -----------------------------------------------------------------------------

class SparkPageCache extends SparkApplication
{
	private $_page_cache;
	private $_page_cache_active;
	private $_page_cache_static;
	private $_page_cache_reject_query_vars;
	private $_send_content_type;
	private $_send_last_modified;
	private $_send_etag;
	private $_expires_headers;
	private $_hasQueryVars;
	private $_cacheable;
	private $_ttl;
	private $_lmts;

	// --------------------------------------------------------------------------

	public function __construct($spark, $config = array())
	{
		parent::__construct($spark, $config);
		
		if (!$cache_params = $this->config->get('page_cache'))
		{
			$cache_params = $this->config->get('cache');
		}

		if (!empty($cache_params['page_cache_active']) && method_exists($this, 'loadCacher'))
		{
			if (isset($cache_params['page_cache_namespace']))
			{
				$cache_params['namespace'] = $cache_params['page_cache_namespace'];
			}

			try
			{
				if ($cache_params['adapter'] === 'database')
				{
					$model = new SparkDBModel();
					$cache_params['connection'] = $model->loadDB();
				}
				
				$this->_page_cache = $this->loadCacher($cache_params);
			}
			catch (Exception $e)
			{
				// would be good to log an error message here
			}
			
			$this->_page_cache_active = true;
			$this->_page_cache_static = !empty($cache_params['page_cache_static']);
			$this->_page_cache_reject_query_vars = !empty($cache_params['page_cache_reject_query_vars']);
			$this->_send_content_type = !empty($cache_params['page_cache_send_content_type']);
			$this->_send_last_modified = !empty($cache_params['page_cache_send_last_modified']);
			$this->_send_etag = !empty($cache_params['page_cache_send_etag']);
			$this->_expires_headers = @$cache_params['page_cache_expires_headers'];
		}
		
		$this->_hasQueryVars = false;
		$this->_cacheable = NULL;
		$this->_ttl = NULL;
		$this->_lmts = NULL;

		// observe exception events so we don't cache error pages

		$this->observer->observe(array($this, 'handleException'), 'SparkApplication:run:exception');

		// observe cache flush requests

		$this->observer->observe(array($this, 'purgeCache'), 'SparkPageCache:request_flush');

		// observe cache disable requests

		$this->observer->observe(array($this, 'disableCache'), 'SparkPageCache:request_disable');

		// observe dispatch events so we can grab the params to check for query variables

		if ($this->_page_cache_reject_query_vars)
		{
			$this->observer->observe(array($this, 'checkQueryVars'), 'SparkApplication:dispatch:before');
		}
	}

	// --------------------------------------------------------------------------

	public function setDefaultTTL($ttl)
	{
		if ($this->_page_cache)
		{
			$this->_page_cache->setDefaultTimeout($ttl);
		}
	}
	
	// --------------------------------------------------------------------------

	public function run()
	{
		// is the page cached?
		
		if ($this->_page_cache_active && $this->_page_cache && ($this->getRequestMethod() === SparkUtil::kRequestMethod_GET))
		{
			if (!$this->_page_cache_reject_query_vars || empty($_GET))
			{
				if ($page = $this->_page_cache->get($this->generateCacheKey()))
				{
					$this->sendCachedPage($page);
					return;
				}
			}
		}
		
		return parent::run();
	}
	
	// --------------------------------------------------------------------------

	public function display($output, $contentType = 'text/html', $status = NULL, $headers = NULL)
	{
		$expDate = false;
		
		if ($this->_page_cache_active && $this->_page_cache && $this->isCacheable($ttl, $lmts))
		{
			if ($expDate = $this->checkExpires($contentType, $this->_expires_headers, $lifetime))
			{
				header('Expires: ' . $expDate);
				header('Cache-Control: max-age='.$lifetime*3600);
			}
			$preTag = '';
			if ($this->_send_content_type)
			{
				$preTag .= $contentType;
			}
			if ($this->_send_last_modified && isset($lmts))
			{
				$lmts = min($lmts, time());
				$preTag .= ';' . intval($lmts);
				header('Last-Modified: ' . gmstrftime('%a, %d %b %Y %H:%M:%S GMT', $lmts));
			}
			if ($this->_send_etag)
			{
				$etag = '"' . 'escher-' . strlen($output) . '.' . crc32($output) . '"';
				$preTag .= ';' . $etag;
				header("ETag: $etag");
			}
			if (!empty($preTag))
			{
				$preTag .= ':';
			}
			if ($this->_page_cache_static)
			{
				$preTag = '';
			}
			$postTag = ($contentType === 'text/html') ? "\n<!-- served from page cache -->\n" : '';
			$this->_page_cache->set($this->generateCacheKey(), $preTag . $output . $postTag, $ttl);
		}
		
		if (!$expDate)
		{
			header('Cache-Control: max-age=0, no-cache, must-revalidate');
		}
		
		return parent::display($output, $contentType, $status, $headers);
	}
	
	// --------------------------------------------------------------------------

	public function setCacheable($cacheable, $ttl = NULL, $lmts = NULL)
	{
		// allow change from true to false, but not vice-versa

		if (!isset($this->_cacheable) || $this->_cacheable)
		{
			$this->_cacheable = $cacheable;
			$this->_ttl = $ttl;
			$this->_lmts = $lmts;
		}
	}

	// --------------------------------------------------------------------------

	public function disableCache($event = NULL)
	{
		$this->_page_cache_active = false;
	}

	// --------------------------------------------------------------------------

	public function checkQueryVars($event, $class, $method, $controller, $params)
	{
		$this->_hasQueryVars = !empty($params['qv']);
	}

	// --------------------------------------------------------------------------

	public function purgeCache($event = NULL)
	{
		if ($this->_page_cache)
		{
			$this->_page_cache->clear();
		}
	}

	// --------------------------------------------------------------------------

	public function handleException($event, $e)
	{
		// don't cache error pages as this can present a DOS vulnerability

		$this->setCacheable(false);
	}

	// --------------------------------------------------------------------------

	private function isCacheable(&$ttl, &$lmts)
	{
		$ttl = $this->_ttl;
		$lmts = $this->_lmts;
		return ($this->_cacheable && ($this->getRequestMethod() === SparkUtil::kRequestMethod_GET) && !$this->_hasQueryVars);
	}

	// --------------------------------------------------------------------------

	private function sendCachedPage($page)
	{
		$contentType = 'text/html';

		if (!$this->_page_cache_static && ($this->_send_content_type || $this->_send_last_modified || $this->_send_etag))
		{
			if (($splitAt = strpos($page, ':')) !== false)
			{
				$preTag = substr($page, 0, $splitAt);
				$splits = explode(';', $preTag);
				$contentType = $splits[0];
				
				// send cache headers and decide if we can return a 304 status
	
				if ($this->_send_last_modified || $this->_send_etag)
				{
					if (isset($splits[2]))
					{
						if (is_numeric($splits[2]))
						{
							$lmts = $splits[2];
							$etag = $splits[1];
						}
						else
						{
							$lmts = $splits[1];
							$etag = $splits[2];
						}
					}
					elseif (isset($splits[1]))
					{
						if (is_numeric($splits[1]))
						{
							$lmts = $splits[1];
						}
						else
						{
							$etag = $splits[1];
						}
					}
	
					if (isset($lmts) && $this->_send_last_modified)
					{
						header('Last-Modified: ' . gmstrftime('%a, %d %b %Y %H:%M:%S GMT', $lmts));
					}
										
					if (!empty($etag) && $this->_send_etag)
					{
						header("ETag: $etag");
					}
					
					// can we send a "304 Not Modified"?
					//
					// prefer eTag because it is a more accurate indicator of page changes than a date
	
					if (!empty($etag) && ($match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) : ''))
					{
						if ($match === $etag)
						{
							while (@ob_end_clean());
							header('HTTP/1.1 304 Not Modified');
							if ($expDate = $this->checkExpires($contentType, $this->_expires_headers, $lifetime))
							{
								header('Expires: ' . $expDate);
								header('Cache-Control: max-age='.$lifetime*3600);
							}
							exit;
						}
					}
	
					elseif (isset($lmts) && ($match = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE']) : ''))
					{
						if (@strtotime($match) >= intval($lmts))
						{
							while (@ob_end_clean());
							header('HTTP/1.1 304 Not Modified');
							if ($expDate = $this->checkExpires($contentType, $this->_expires_headers, $lifetime))
							{
								header('Expires: ' . $expDate);
								header('Cache-Control: max-age='.$lifetime*3600);
							}
							exit;
						}
					}
				}
	
				$page = substr($page, $splitAt + 1);
			}
		}
		
		if ($expDate = $this->checkExpires($contentType, $this->_expires_headers, $lifetime))
		{
			header('Expires: ' . $expDate);
			header('Cache-Control: max-age='.$lifetime*3600);
		}
		else
		{
			header('Cache-Control: max-age=0, no-cache, must-revalidate');
		}

		parent::display($page, $contentType);
	}

	// --------------------------------------------------------------------------

	private function checkExpires($contentType, $expiresList, &$lifetime)
	{
		if (!empty($expiresList))
		{
			foreach ($expiresList as $type => $ttl)
			{
				$type = '#^' . str_replace('*', '.*', $type) . '$#';
				if (preg_match($type, $contentType))
				{
					$lifetime = $ttl;
					return gmdate('D, d M Y H:i:s', strtotime("+ {$ttl} hours")) . ' GMT';
				}
			}
		}
		return false;
	}

	// --------------------------------------------------------------------------

	private function generateCacheKey($key = NULL)
	{
		// Whether or not the application suppresses caching of pages with query parameters,
		// we always strip them if present to avoid denial-of-service vulnerability.
		// This means that all pages with the same base url will share the same cached page,
		// regardless of query parameters.
		
		return rtrim(!empty($key) ? $key : preg_replace('/\?.*$/', '', SparkUtil::request_uri()), '/');
	}

	// --------------------------------------------------------------------------
}
